local getmetatable = getmetatable
local setmetatable = setmetatable
local module = module
local require = require
local print = print
local type = type
local error = error
local string = string
local pcall = pcall
local package = package
local tostring = tostring
local tonumber = tonumber
local os = os
local pairs = pairs

module('fit.Fixture')
_VERSION='0.2'

local Fixture = require('fit.Fixture')
local Counts = require('fit.Counts')

-- Annotation -------------------------

green   =   "#cfffcf"
red     =   "#ffcfcf"
gray    =   "#efefef"
yellow  =   "#ffffcf"

local function label(s)
    return " <font size=-1 color=\"#c08080\"><i>" .. s .. "</i></font>"
end

local function escape(s)
    s = string.gsub(s, "&", "&amp;")
    s = string.gsub(s, "<", "&lt;")
    s = string.gsub(s, " ", "&nbsp;")
    s = string.gsub(s, "\r\n", "<br />")
    s = string.gsub(s, "\r", "<br />")
    s = string.gsub(s, "\n", "<br />")
    return s
end

local function right(self, cell)
    cell:addToTag(" bgcolor=\"" .. green .. "\"")
    self.counts.right = self.counts.right + 1
end

local function wrong(self, cell, actual)
    cell:addToTag(" bgcolor=\"" .. red .. "\"")
    self.counts.wrong = self.counts.wrong + 1

    if actual ~= nil then
        cell:addToBody(self.label("expected") .. "<hr>" ..
                self.escape(actual) .. self.label("actual"))
    end
end

local function ignore(self, cell)
    cell:addToTag(" bgcolor=\"" .. gray .. "\"")
    self.counts.ignores = self.counts.ignores + 1
end

local function errorCell(self, cell, message)
    cell.body = escape(cell:text())
    cell:addToBody("<hr><pre>" .. escape(message) .. "</pre>")
    cell:addToTag(" bgcolor=\"" .. yellow .. "\"")
    self.counts.exceptions = self.counts.exceptions + 2
end

local function exception(self, cell, err)
    if type(err) == "string" then print(err) end
    self:errorCell(cell, err)
    self.counts.exceptions = self.counts.exceptions + 1
end

local function info(self, cell, message)
    if cell == nil then
        return " <font color=\"#808080\">" .. message .. "</font>"
    else
        return cell:addToBody(" <font color=\"#808080\">" ..
                message .. "</font>")
    end
end

-- Trim leading and trailing whitespace
local function trim(str)
    return (string.gsub(str, "^%s*(.-)%s*$", "%1"))
end

-- Convert table to string
local function tableToString(t, indent)
    s = "table {\n"
    for k,v in pairs(t) do
        if (type(v) == "table") then
            s = s .. indent .. k .. " = " .. tableToString(v, indent .. ident)
            s = s .. ";\n"
        elseif type(v) == "string" then
            s = s .. indent .. k .. " = \"" .. v .. "\";\n"
        else
            s = s .. indent .. k .. " = " .. tostring(v) .. ";\n"
        end
    end

    s = s .."}"
    return s
end

-- Split string by given seperator
local function deconstruct(argstr, sep)
    if type(argstr) ~= "string" then
        return {} -- no arguments
    end

    -- Single argument
    if (not argstr:match(sep)) then
        return {argstr}
    end

    -- Multiple arguments separated by the string sep
    arguments = {}
    local first = argstr:find(sep)
    while first do
        arguments[#arguments+1] = argstr:sub(1, first - 1)
        argstr = argstr:sub(first+1, #argstr)
        first = argstr:find(sep)
    end
    -- last field
    if #argstr > 0 then
        arguments[#arguments+1] = argstr
    end

    return arguments
end

-- Converts arguments from strings to their proper types/values
local function translate(self, argstr)
    -- trim the string
    argstr = trim(argstr)

    -- Number
    if (tonumber(argstr) ~= nil) then
        return tonumber(argstr)
    end

    -- Boolean
    if (argstr:match("^[tT][rR][uU][eE]$")) then
        return true
    elseif (argstr:match("^[fF][aA][lL][sS][eE]$")) then
        return false
    end

    -- Quoted String (return unquoted)
    if (argstr:match("^[\"'].*[\"']$")) then
        argstr = (string.gsub(argstr, "^[\"']", ""))
        argstr = (string.gsub(argstr, "[\"']$", ""))
        return argstr
    end

    -- Table
    if (argstr:match("^{.-}$")) then
        return self:importTable(argstr)
    end

    -- Local Variable
    fields = deconstruct(argstr, "%.")
    if (self[fields[1]] ~= nil) then
        value = self
        for i=1,#fields do
            value = value[fields[i]]
            if (value == nil) then return nil end
        end
        return value
    end

    -- String
    return argstr
end

-- Convert table string representation to table
local function importTable(self, tblstr)
    -- Trim the string
    tblstr = trim(tblstr)
    -- Remove newlines
    tblstr = (string.gsub(tblstr, "\n", ""))

    tbl = {}

    -- Not a proper table string, return nil
    if (not tblstr:match("^{.*}$")) then
       return nil
    end

    tblstr = (string.gsub(tblstr, "^{(.-)}$", "%1"))
    fields = deconstruct(tblstr, ";")
    for i=1,#fields do
        field = trim(fields[i])

        -- Last entry will be an empty string (between the last ; and } characters)
        if (field == "") then
            -- skip it
        -- field name specified
        elseif (field:match("[^=]+=(.+)")) then
            name = trim((string.gsub(field, "=.-$", "")))
            value = self:translate(trim((string.gsub(field, "^[^=]+=", ""))))
            tbl[name] = value
        else
            tbl[#tbl + 1] = self:translate(field)
        end
    end

    return tbl
end

-- Compare first table to second table
-- They are considered equal if tbl2 contains the same key/value pairs as tbl1
-- tbl2 is still equal to tbl1 if it is a superset of tbl1.
local function tablesEqual(tbl1, tbl2)
    equal = true

    if (tbl1 == nil or tbl2 == nil) then return false end

    for k,v in pairs(tbl1) do
        if (type(v) == "table" and type(tbl2[k]) == "table") then
            equal = equal and tablesEqual(v, tbl2[k])
        else
            equal = equal and (v == tbl2[k])
        end
    end

    return equal
end

local function check(self, cell, func)
    if type(func) ~= "function" then
        self:errorCell(cell, "Function DNE")
    end

    local text = cell:text()
    if (text == "") then
        local status, result = pcall(func, self)
        if status then
            self:info(cell, tostring(result))
        else
            self:info(cell, "error")
        end
    elseif text == "error" then
        status, result = pcall(func, self)

        if status then
            self:wrong(cell, tostring(result))
        else
            self:right(cell)
        end
    else
        local status, result = pcall(func, self)
        if status then
            if type(result) == "string" then
                if result == text then
                    self:right(cell)
                else
                    self:wrong(cell, result)
                end
            elseif type(result) == "number" then
                if result == tonumber(text) then
                    self:right(cell)
                else
                    self:wrong(cell, tostring(result))
                end
            elseif result == nil then
                if text == "nil" then
                    self:right(cell)
                else
                    self:wrong(cell, "nil")
                end
            elseif type(result) == "table" then
                local text = self:importTable(text)

                -- compare the two tables
                if (tablesEqual(result, text)) then
                    self:right(cell)
                else
                    self:wrong(cell, tableToString(result, "  "))
                end
            elseif type(result) == "boolean" then
                if (text:match("[tT][rR][uU][eE]") and result) then
                    self:right(cell)
                elseif (text:match("[fF][aA][lL][sS][eE]") and not result) then
                    self:right(cell)
                else
                    self:wrong(cell, tostring(result))
                end
            else
                self:errorCell(cell, "Bad result type: " .. type(result))
            end
        else
            self:exception(cell, result)
        end
    end
end

-- Traversal --------------------------

local function doCell(self, cell)
    self:ignore(cell)
end

local function doCells(self, cells)
    i = 1
    while cells ~= nil do
        -- try
        self:doCell(cells, i)
        -- catch(Exception e)
        --     exception(cells, e)

        cells = cells.more
        i = i + 1
    end
end

local function doRow(self, row)
    self:doCells(row.parts)
end

local function doRows(self, rows)
    while rows ~= nil do
        more = rows.more
        self:doRow(rows)
        rows = more
    end
end

local function doTable(self, table)
    self:doRows(table.parts.more)
end

-- errors when fixture isn't found
local function loadFixture(name)
    notFound = "There was an error loading fixture \"" .. name .."\":\n";

    local status, namedFixture = pcall(require, name)
    if not status then error(notFound .. namedFixture) end
    return namedFixture()
end

local function getArgsForTable(self, table)
    argumentList = {}
    parameters = table.parts.parts.more
    while parameters ~= nil do
        argumentList[#argumentList + 1] = parameters:text()
        parameters = parameters.more
    end
    self.args = argumentList
end

local function fixtureName(tables)
    return tables:at(1,1,1)
end

-- uses base Fixture when it can't find the one specified in the table
local function getLinkedFixtureWithArgs(self, tables)
    header = tables:at(1,1,1)

    status, fixture = pcall(loadFixture, header:text())
    if not status then  -- error, print message, use base Fixture
        print(fixture)
        print("Using the base Fixture instead (this fixture ignores all cells!)")
        fixture = Fixture()
    end

    fixture.counts = self.counts
    fixture.summary = self.summary
    fixture:getArgsForTable(tables)
    return fixture
end

local function interpretFollowingTables(self, tables)
    tables = tables.more
    while tables ~= nil do
        name = fixtureName(tables)
        if name ~= nil then
            fixture = getLinkedFixtureWithArgs(self, tables)
            fixture:doTable(tables)
        end
        tables = tables.more
    end
end

local function interpretTables(self, tables)
    self:getArgsForTable(tables) -- get them again for the new fixture object
    self:doTable(tables)
    interpretFollowingTables(self, tables)
end

local function doTables(self, tables)
    self.summary["run date"] = os.date()

    if tables ~= nil then
        fixtureNameVar = fixtureName(tables)
        if fixtureNameVar ~= nil then
            fixture = getLinkedFixtureWithArgs(self, tables)
            fixture:interpretTables(tables)
        end
    end
end

-- Constructor ------------------------

do
    local counts = Counts()
    local summary = {}
    function __call()
        local aFixture = {}

        -- Public Functions
        aFixture.doTables = doTables
        aFixture.doTable = doTable
        aFixture.fixtureName = fixtureName
        aFixture.loadFixture = loadFixture
        aFixture.doRows = doRows
        aFixture.doRow = doRow
        aFixture.doCells = doCells
        aFixture.doCell = doCell
        aFixture.right = right
        aFixture.wrong = wrong
        aFixture.info = info
        aFixture.ignore = ignore
        aFixture.errorCell = errorCell
        aFixture.exception = exception
        aFixture.label = label
        aFixture.escape = escape
        aFixture.check = check
        aFixture.getArgsForTable = getArgsForTable
        aFixture.interpretTables = interpretTables

        aFixture.importTable = importTable
        aFixture.translate = translate
        aFixture.deconstruct = deconstruct

        -- Global Variables
        aFixture.args = {}
        aFixture.counts = counts
        aFixture.summary = summary
        return aFixture
    end
end

setmetatable(_M, _M)
